2025-05-05 리팩터링 시 LSP 원칙 위배사례 발견 및 ReservationPolicyOptions 객체 도입제안

Q. 공통인터페이스에 사용되지 않는 인자 발생

나 "리스코프 치환 원칙"에 관해 물어볼 것이 있어. ReservationPolicy.isReservable 이라는 공통인터페이스의 courtId 인자를 사용하지 않는 Policy가 생겼는데 테스트코드를 작성하다보니 아래와 같이 쓰게 되더라고. 이 경우 추후 치환가능성에 지장이 생기지 않을까 궁금해.

    it('월요일 12:00 - 14:00을 예약하는 경우 성공해야한다', async () => {
      await expect(
        policy.isReservable(
          {
            from: setUpKstTimeOutUtc(SAMPLE_PARSE_DATE.MONDAY, {
              hour: 12,
            }),
            to: setUpKstTimeOutUtc(SAMPLE_PARSE_DATE.MONDAY, {
              hour: 14,
            }),
          },
          '',
          mockTicket.id,
        ),
      ).resolves.toEqual({ success: true });
    });

아래는 TicketPlanPolicy 본문이야:

Injectable()
export class TicketPlanPolicy implements ReservabilityPolicy {
  constructor(private readonly ticketRepo: TicketRepository) {}
  async isReservable(
    time: ITimeDuration,
    _courtId: ObjectId,
    ticketId: ObjectId,
  ): Promise<ReservationResult> {
    // ?Ticket <<Aggregate Root>> *-- TicketPlan 구조로 가져가야 할까?
    // 💡 결론:
    // 👉 Ticket은 Aggregate Root, TicketPlan은 외부 참조 모델로 분리하는 것이 좋습니다.
    // Ticket이 자신의 유효성을 판단하기 위해 TicketPlan 정보를 읽기 전용으로 참조하는 구조가 적절합니다.
    const ticket = await this.ticketRepo.getTicketById(ticketId);
    if (!ticket) {
      return { success: false, reason: 'TICKET_NOT_FOUND' };
    }
    const ticketPlan = ticket.ticketPlanModel;
    if (!ticketPlan) {
      return { success: false, reason: 'TICKET_PLAN_NOT_FOUND' };
    }

    const isTicketPlanAvailable = isIncludeBusinessDay(
      ticketPlan.accessDuration,
      time.from.toDate(),
      time.to.toDate(),
    );

    return {
      success: isTicketPlanAvailable,
      reason: isTicketPlanAvailable ? undefined : 'TICKET_PLAN_NOT_AVAILABLE',
    };
  }
}

좋은 질문이에요. 이 상황은 리스코프 치환 원칙 (Liskov Substitution Principle, LSP) 이 실제 코드 설계에 어떤 영향을 주는지를 고민하는 좋은 예입니다. 당신의 질문을 정리하면 다음과 같습니다:

ReservationPolicy.isReservable(time, courtId, ticketId)이라는 공통 인터페이스를 갖는 여러 정책 중, TicketPlanPolicycourtId를 전혀 사용하지 않는다. 그런데 테스트를 작성할 때 어쩔 수 없이 courtId 자리에 ''를 넣어야 했다. 이것이 LSP 위배일까?


🔍 리스코프 치환 원칙이란?

LSP는 “상위 타입 객체는 하위 타입 객체로 치환해도 프로그램의 정확성이 유지되어야 한다” 는 원칙입니다.
즉, ReservationPolicy를 의존하는 클라이언트는 TicketPlanPolicy, CourtBusinessDayPolicy, HolidayPolicy 등 어떤 구체 구현이 와도 API 사용 방법이나 의미적 일관성이 유지되어야 합니다.


🧠 현 상황에서의 위배 여부 판단

당신의 테스트 코드에서 courtId''을 넣는 것은 다음과 같은 위험 신호를 암시합니다:

🚩 1. 의미 없는 인자 (courtId: '')


✅ 해결 방안 제안

1. isReservable() 시그니처를 더 유연하게 만들기

interface ReservabilityPolicy {
  isReservable(time: ITimeDuration, options: ReservationPolicyOptions): Promise<ReservationResult>;
}

interface ReservationPolicyOptions {
  courtId?: ObjectId;
  ticketId?: ObjectId;
}

2. 또는 정책을 나눠서 적용하기

interface CourtPolicy extends ReservabilityPolicy {
  isReservable(time: ITimeDuration, courtId: ObjectId): Promise<ReservationResult>;
}

interface TicketPolicy extends ReservabilityPolicy {
  isReservable(time: ITimeDuration, ticketId: ObjectId): Promise<ReservationResult>;
}

✨ 결론

항목 판단
courtId 자리에 ''을 넣은 테스트는 문제인가? 예. LSP를 잠재적으로 위반합니다.
지금의 인터페이스가 LSP에 어긋나는가? 약간 어긋납니다. 일부 구현체는 특정 인자가 의미 없는데도 강제로 받게 되어 있기 때문입니다.
해결 방안은? options 객체 기반 시그니처로 바꾸거나, 정책을 세분화해서 관리하세요.

필요하시다면 ReservationPolicySet 리팩토링까지도 예시로 보여드릴 수 있어요. 궁금하신가요?


Q. ReservationPolicyOptions 예제

물론입니다. 아래는 ReservationPolicyOptions를 활용해 ReservationPolicy 인터페이스를 더 유연하고 LSP 친화적으로 바꾸는 예제입니다.


✅ Step 1: 공통 옵션 타입 정의

export interface ReservationPolicyOptions {
  courtId?: ObjectId;
  ticketId?: ObjectId;
  userId?: ObjectId;
  // 추가적으로 필요한 정책 옵션을 여기에 확장
}

✅ Step 2: 인터페이스 시그니처 수정

export interface ReservationPolicy {
  isReservable(
    time: ITimeDuration,
    options: ReservationPolicyOptions,
  ): Promise<ReservationResult>;
}

✅ Step 3: 구현체 예시 – TicketPlanPolicy

@Injectable()
export class TicketPlanPolicy implements ReservationPolicy {
  constructor(private readonly ticketRepo: TicketRepository) {}

  async isReservable(
    time: ITimeDuration,
    options: ReservationPolicyOptions,
  ): Promise<ReservationResult> {
    if (!options.ticketId) {
      return { success: false, reason: 'TICKET_ID_REQUIRED' };
    }

    const ticket = await this.ticketRepo.getTicketById(options.ticketId);
    if (!ticket) return { success: false, reason: 'TICKET_NOT_FOUND' };
    if (!ticket.ticketPlanModel) return { success: false, reason: 'TICKET_PLAN_NOT_FOUND' };

    const isAvailable = isIncludeBusinessDay(
      ticket.ticketPlanModel.accessDuration,
      time.from.toDate(),
      time.to.toDate(),
    );

    return {
      success: isAvailable,
      reason: isAvailable ? undefined : 'TICKET_PLAN_NOT_AVAILABLE',
    };
  }
}

✅ Step 4: 클라이언트 코드 (예: 테스트 or ReservationService)

await policy.isReservable(
  {
    from: dayjs('2025-05-05T12:00:00'),
    to: dayjs('2025-05-05T14:00:00'),
  },
  {
    ticketId: mockTicket.id,
  },
);

✅ 장점


필요하시다면 CompositeReservationPolicy도 이 구조에 맞게 재작성된 예시 드릴게요. 보여드릴까요?